5.09. Типы данных и переменные
Типы данных и переменные
Переменные и константы: семантика и назначение
В Kotlin существует два ключевых способа связывания имени с данными: переменные (var) и константы (val). Несмотря на сходство в синтаксисе объявления, различия между ними принципиальны и влияют как на поведение программы, так и на её читаемость и надёжность.
Константы (val)
Ключевое слово val (сокращение от value) вводит неизменяемую ссылку на значение. После инициализации переменная, объявленная с val, не может быть переприсвоена другому значению. Это не означает, что само значение обязательно «неизменяемо» в смысле внутреннего состояния — например, val list = mutableListOf(1, 2, 3) остаётся изменяемым списком, и к нему можно добавлять элементы. Однако сама ссылка list останется неизменной: попытка написать list = mutableListOf(4, 5) приведёт к ошибке компиляции.
Константы рекомендуются по умолчанию во всех случаях, когда повторное присваивание не требуется. Это соответствует парадигме immutability by default, повышает предсказуемость кода и упрощает рассуждения о его поведении, особенно в многопоточной среде. Компилятор Kotlin может выполнять дополнительные оптимизации, зная, что значение ссылки не изменится.
Переменные (var)
Ключевое слово var (сокращение от variable) объявляет изменяемую переменную. Ей можно присвоить новое значение в любой момент после инициализации, при условии, что новое значение совместимо по типу с объявлением. Переменные используются для хранения состояния, которое по своей природе изменяется в процессе выполнения — например, счётчик цикла, накопительная сумма, текущая позиция в потоке данных.
Kotlin поощряет декларативный стиль программирования, где данные преобразуются через функции, а не модифицируются на месте. Поэтому чрезмерное использование var считается плохой практикой, если только изменяемость не является неотъемлемой частью логики (например, при оптимизации производительности через мутабельные аккумуляторы).
Объявление и инициализация
Объявление переменной или константы в Kotlin состоит из трёх частей:
- Ключевого слова (
valилиvar); - Идентификатора — имени переменной, удовлетворяющего правилам именования (начинается с буквы или символа подчёркивания, далее буквы, цифры,
$,_); - Необязательной аннотации типа (например,
: Int); - Оператора присваивания
=; - Выражения инициализации — литерала, вызова функции, арифметического выражения и т.п.
Примеры:
val pi: Double = 3.1415926535
var counter: Int = 0
val greeting = "Добро пожаловать"
В первых двух строках тип указан явно. В третьем случае компилятор выводит, что значение "Добро пожаловать" является строковым литералом, и, следовательно, тип переменной greeting — String. Это — работа механизма вывода типов.
Kotlin требует, чтобы все переменные и константы были инициализированы до первого использования. Отложенная инициализация возможна, но только при явном указании через специальные механизмы (lateinit var, делегированные свойства), которые рассматриваются в отдельных главах.
Явное указание типа: когда и зачем
Хотя вывод типов устраняет большую часть необходимости в аннотациях, существуют случаи, когда явное указание типа предпочтительно:
- Повышение читаемости в сложных выражениях, где выводимый тип неочевиден читающему;
- Ограничение обобщённого типа, когда литерал может иметь несколько интерпретаций (например,
0может бытьInt,Long,Byte— без аннотации или контекста компилятор выберетInt); - Документирование намерений, особенно при работе с API, где конкретный тип критичен;
- Работа с nullable-типами, где важно явно различать
StringиString?; - Объявление свойств класса без немедленной инициализации, где тип должен быть известен компилятору заранее (например, в теле класса:
var user: User? = null).
В остальных случаях предпочтителен краткий синтаксис без аннотации.
Встроенные типы данных
Kotlin не использует «примитивы» в том смысле, как это сделано в Java на уровне виртуальной машины. Вместо этого все значения являются объектами, но компилятор и среда выполнения выполняют специализацию — для эффективности базовые типы (числа, логические значения, символы) представляются соответствующими JVM-примитивами (int, long, boolean и т.д.) на этапе генерации байт-кода, если это возможно. Для программиста же весь набор типов выглядит единообразно: у каждого есть методы и свойства, доступные через точечную нотацию.
Ниже приведено описание встроенных типов, используемых для представления скалярных значений.
Целочисленные типы
Kotlin предоставляет четыре знаковых целочисленных типа фиксированной разрядности. Все они реализуют интерфейс Number, что позволяет выполнять базовые арифметические операции и преобразования.
| Тип | Размер в битах | Диапазон значений | Литералы и особенности |
|---|---|---|---|
Byte | 8 | от –128 до 127 | val b: Byte = 100 |
Short | 16 | от –32 768 до 32 767 | val s: Short = 30_000 |
Int | 32 | от –2 147 483 648 до 2 147 483 647 | val i = 42 (по умолчанию) |
Long | 64 | от –9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 | val l = 123L (суффикс L обязателен) |
Отметим несколько важных моментов:
- Отсутствие беззнаковых типов в стандартной библиотеке до Kotlin 1.9. Ранние версии языка поддерживали только знаковые целые. Начиная с Kotlin 1.9, в экспериментальном режиме доступны беззнаковые типы (
UByte,UShort,UInt,ULong), но их использование требует явного включения фичи и пока не рекомендуется для основных проектов. - Литералы без суффикса всегда интерпретируются компилятором как
Int, если значение укладывается в его диапазон. Если значение выходит за пределыInt, компилятор попытается использоватьLong, но только при наличии суффиксаL. Попытка записатьval big = 3_000_000_000(безL) вызовет ошибку компиляции, поскольку значение превышает лимитInt. - Разделители разрядов (
_) разрешены внутри числовых литералов и игнорируются компилятором:1_000_000,0xFF_EC_DE_5E.
Вещественные типы
Для представления чисел с плавающей точкой Kotlin предоставляет два типа, соответствующих стандарту IEEE 754.
| Тип | Размер в битах | Точность (примерно) | Литералы |
|---|---|---|---|
Float | 32 | 6–7 десятичных цифр | 3.14f, 2e10f |
Double | 64 | 15–16 десятичных цифр | 3.14, 2.718281828459045, 1.5e-10 |
Важные детали:
- Литералы без суффикса интерпретируются как
Double. Для полученияFloatнеобходимо явно указать суффиксfилиF. - Тип
Doubleиспользуется по умолчанию в математических вычислениях, если не требуется экономия памяти или совместимость с внешним интерфейсом (например, OpenGL). - Поскольку числа с плавающей точкой хранятся в двоичном виде, операции с ними не являются абсолютно точными. Это общеизвестное ограничение арифметики с плавающей точкой, и оно не специфично для Kotlin.
Логический тип Boolean
Тип Boolean принимает ровно два значения: true и false. Он используется в условиях, циклах, логических выражениях. В Kotlin отсутствует неявное приведение Boolean к числовому типу и наоборот — невозможно написать if (1) или if (null). Это исключает распространённые ошибки, характерные для языков с «истинностью» значений.
Символьный тип Char
Тип Char представляет один символ в кодировке UTF-16. Литералы записываются в одинарных кавычках: 'A', 'я', '€', '\n', '\u00A9'. Несмотря на то, что в JVM символы хранятся как 16-битные единицы, Kotlin скрывает детали представления и предоставляет методы для работы с Unicode-символами (например, code, isDigit()).
Важно: Char — это не число, и его нельзя напрямую использовать в арифметических операциях. Для получения числового кода символа следует использовать свойство code:
val c = 'A'
val code = c.code // 65
Строковый тип String
String — неизменяемая последовательность символов (Char). Строковые литералы заключаются в двойные кавычки: "Привет", "Kotlin\n", """Многострочный текст""". Kotlin поддерживает интерполяцию строк через символ $:
val name = "Тимур"
val message = "Здравствуйте, $name!"
val lengthInfo = "Длина имени: ${name.length}"
Внутри ${} может стоять любое выражение, результат которого преобразуется в строку вызовом toString().
Строки в Kotlin индексируются от нуля, поддерживают безопасный доступ к символам (str.getOrNull(index)), итерацию, сравнение лексикографически. Все строки неизменяемы, что гарантирует потокобезопасность и позволяет эффективно использовать внутренний пул строк.
Вывод типов: принципы и ограничения
Механизм вывода типов в Kotlin реализован на уровне компилятора и активируется при инициализации переменной или константы значением, тип которого однозначно определим. Рассмотрим несколько сценариев.
Прямой вывод из литерала
val x = 42 // Int
val y = 42L // Long
val z = 3.14 // Double
val w = 3.14f // Float
val flag = true // Boolean
val letter = 'K' // Char
val text = "Hello" // String
Компилятор сопоставляет форму литерала с известными шаблонами и назначает соответствующий тип.
Вывод из вызова функции
fun currentTimeMillis(): Long = System.currentTimeMillis()
val now = currentTimeMillis() // Long
Здесь тип now выводится из объявления возвращаемого значения функции currentTimeMillis().
Вывод в контексте
В некоторых случаях тип определяется контекстом использования — например, при передаче лямбда-выражения в функцию:
listOf(1, 2, 3).map { it * 2 } // it имеет тип Int, результат — List<Int>
Компилятор знает сигнатуру map, ожидает преобразователь T → R, и, зная, что исходный список — List<Int>, выводит T = Int, а затем R = Int, так как it * 2 — выражение типа Int.
Когда вывод невозможен
Если переменная объявляется без инициализации или инициализируется выражением с неопределённым типом (например, null без аннотации), компилятор требует явного указания:
val name: String? // допустимо: тип объявлен, инициализация может быть отложена
val size // ошибка: тип неизвестен, инициализатор отсутствует
val data = null // ошибка: null сам по себе не имеет типа
В последнем случае необходимо либо указать тип (val data: String? = null), либо инициализировать значением, не равным null.
Nullable-типы: философия и синтаксис
В большинстве современных языков программирования ссылочные типы по умолчанию допускают значение null, что означает «отсутствие объекта». Эта особенность — источник множества ошибок времени выполнения, в первую очередь NullPointerException (NPE), получившего известность как «миллиардная ошибка» по выражению Тони Хоара, который ввёл концепцию null в ALGOL. Kotlin решает эту проблему радикально: по умолчанию все типы не допускают null.
Это достигается за счёт разделения пространства типов на две категории:
- Non-nullable типы — например,
String,Int,List<String>. Переменная такого типа гарантированно содержит значение. Присвоить ейnullневозможно — компилятор выдаст ошибку. - Nullable типы — обозначаются суффиксом
?:String?,Int?,List<String>?. Переменная такого типа может содержать либо значение соответствующего non-nullable типа, либоnull.
Таким образом, возможность отсутствия значения становится частью сигнатуры типа, а не скрытым свойством реализации. Это позволяет компилятору отслеживать все потенциально опасные операции на этапе компиляции.
Объявление nullable-переменных
Объявление nullable-переменной ничем не отличается по структуре от обычного, кроме добавления ? к имени типа:
var name: String? = "Тимур"
name = null // допустимо
val count: Int? = null
val items: List<String>? = null
Обратите внимание: суффикс ? применяется к всему типу, а не к имени переменной. Это означает, что String? — это отдельный, самостоятельный тип, связанный с String отношением субтипирования: String является подтипом String?, но не наоборот. Эта иерархия позволяет безопасно присваивать non-nullable значения nullable-переменным, но запрещает обратное без проверки.
Попытка присвоить null non-nullable переменной
Следующий код не скомпилируется:
val message: String = null // Ошибка: Type mismatch.
// Required: String
// Found: Nothing?
Компилятор сообщает, что требуется String, а предоставлено null, тип которого в Kotlin интерпретируется как Nothing? — специальный нижний тип, не имеющий значений кроме null. Таким образом, ошибка обнаруживается до запуска программы.
Аналогично запрещено вызывать методы или обращаться к свойствам nullable-переменной напрямую:
val text: String? = "Пример"
println(text.length) // Ошибка: Only safe (?.) or non-null asserted (!!.) calls are allowed
Компилятор требует, чтобы программист явно подтвердил осознанность риска или предоставил альтернативу.
Операторы работы с nullable-типами
Kotlin предоставляет набор операторов, позволяющих безопасно и лаконично манипулировать nullable-значениями. Их использование — выражение конкретной стратегии обработки отсутствующих данных.
Оператор безопасного вызова (?.)
Оператор ?. позволяет выполнить вызов метода или доступ к свойству только в том случае, если ссылка не равна null. Если значение null, результатом всего выражения будет null.
val text: String? = "Привет"
val length: Int? = text?.length // 6
val empty: String? = null
val len: Int? = empty?.length // null
Обратите внимание на тип результата: Int?, а не Int. Это логично — поскольку исходное значение может быть null, результат также может быть null. Оператор ?. сохраняет nullability в цепочке вызовов:
val author: Person? = getAuthor() // предположим, что Person имеет свойство name: String
val firstChar: Char? = author?.name?.firstOrNull()
Здесь, если author == null или author.name == null, выражение завершится с null, не вызвав исключения. Метод firstOrNull() возвращает Char?, что делает цепочку полностью безопасной.
Оператор элвиса (?:)
Оператор «элвис» (?:) — альтернатива тернарному условию, специализированная для обработки null. Он возвращает левый операнд, если он не равен null, и правый — в противном случае.
val name: String? = null
val displayName = name ?: "Гость" // "Гость"
val configPath: String? = readConfigPath()
val actualPath = configPath ?: "/etc/app.conf"
Правый операнд может быть любым выражением, включая вызов функции, бросание исключения или логирование:
val userId: String? = request.getParameter("id")
val id = userId ?: throw IllegalArgumentException("ID обязателен")
Важно: правый операнд не вычисляется, если левый не null. Это поведение lazy evaluation обеспечивает эффективность и безопасность.
Оператор принудительного разыменования (!!)
Оператор !! — небезопасный оператор, который превращает nullable-значение в non-nullable, бросая NullPointerException, если значение равно null. Его следует использовать крайне редко — только когда программист абсолютно уверен в ненулевости значения, а компилятор не может это подтвердить (например, при взаимодействии с Java-кодом без аннотаций @NonNull).
val s: String? = possiblyNullString()
val len = s!!.length // если s == null → NPE
Применение !! — сигнал о том, что гарантии языка нарушены. В хорошо спроектированном Kotlin-коде его наличие должно быть обосновано и, по возможности, локализовано.
Безопасное приведение (as?)
Оператор as? выполняет приведение типа и возвращает null, если приведение невозможно, вместо выбрасывания ClassCastException.
val obj: Any = "Текст"
val str: String? = obj as? String // "Текст"
val num: Int? = obj as? Int // null
Это особенно полезно в сочетании с ?::
val value = input as? String ?: input.toString()
Smart cast: автоматический вывод non-null состояния
Одна из сильнейших сторон Kotlin — механизм smart cast (умное приведение типов). После проверки переменной на null в условиях if, when, или при использовании оператора !! в узком контексте, компилятор запоминает это знание и позволяет использовать переменную как non-nullable внутри блока, где гарантия сохраняется.
fun process(text: String?) {
if (text != null) {
// Внутри этого блока 'text' автоматически имеет тип String
println("Длина: ${text.length}") // OK: нет ?. и !!
println("Первый символ: ${text[0]}") // OK
}
// За пределами блока — снова String?
}
Smart cast работает с множеством конструкций:
- Проверка
is/!isдля приведения типов; - Цепочки условий (
text != null && text.isNotEmpty()); - Использование
let,also,runс nullable-приёмниками.
Пример с let:
text?.let { nonNullText ->
// nonNullText имеет тип String
println("Обработка: $nonNullText")
}
Здесь let вызывается только если text не null, и лямбда получает параметр non-nullable типа. Это идиоматичный способ выполнить побочный эффект при наличии значения.
Важное ограничение: smart cast не работает для var-переменных, если между проверкой и использованием возможна модификация из другого потока или через setter. В таких случаях компилятор сохраняет nullable-тип, чтобы избежать гонки данных. Для val ограничение не действует, так как значение неизменно.
Работа с nullable-числами и примитивами
Для числовых типов nullable-варианты (Int?, Double? и т.д.) ведут себя аналогично ссылочным, но требуют особого внимания при арифметике. Арифметические операторы (+, -, *, /) не определены для nullable-типов напрямую:
val a: Int? = 5
val b: Int? = 3
// val sum = a + b // Ошибка: Operator '+' cannot be applied to 'Int?' and 'Int?'
Необходимо либо использовать безопасные операторы в цепочке, либо преобразовать к non-nullable:
val sum = a?.plus(b ?: 0) // 5 + 3 = 8
val product = if (a != null && b != null) a * b else null
Альтернатива — использование функций-расширений из стандартной библиотеки, например takeIf, let, или написание собственных утилит.
Сравнение nullable-чисел с помощью операторов <, <=, >, >= также запрещено. Разрешены только == и !=, где null == null → true, а null == 5 → false.
Практические рекомендации по использованию nullable-типов
-
Избегайте nullable там, где это возможно. Если значение всегда должно присутствовать (например, идентификатор сущности после сохранения в БД), используйте non-nullable тип. Это делает контракт API чётким.
-
Используйте
valи non-nullable по умолчанию. Меняйте наvarи?только при наличии веских причин. -
Не злоупотребляйте
!!. Если вы часто используете!!, это признак того, что:- либо вы взаимодействуете с небезопасным Java API — в этом случае стоит обернуть вызов в функцию с явной обработкой
null; - либо логика программы не гарантирует наличие значения — тогда следует пересмотреть архитектуру.
- либо вы взаимодействуете с небезопасным Java API — в этом случае стоит обернуть вызов в функцию с явной обработкой
-
Предпочитайте
?:и?.letявнымif-проверкам, когда это улучшает читаемость. Однако для сложной логики с несколькими ветвлениямиifостаётся более прозрачным. -
Документируйте причину nullable-состояния. Если свойство может быть
null, укажите в KDoc, почему: «не инициализировано до первого вызова», «отсутствует в legacy-данных», «опциональный параметр API». -
Используйте sealed-классы или
Resultдля представления ошибок и альтернативных состояний, когдаnullне передаёт достаточной семантики. Например, вместоString?для результата парсинга лучше использоватьsealed interface ParseResult { data class Success(val value: String) : ParseResult; object Failure : ParseResult }.
Тип Nothing и его роль в системе типов
Для полноты картины упомянем тип Nothing. Это нижний тип в иерархии Kotlin — он является подтипом всех остальных типов и не имеет значений. Единственный способ «получить» значение Nothing — завершить выполнение (например, бросить исключение или вызвать exitProcess).
fun fail(message: String): Nothing {
throw IllegalStateException(message)
}
Здесь возвращаемый тип Nothing сообщает компилятору, что функция никогда не завершится нормально. Это позволяет, например, использовать fail в ветке else, не нарушая правила инициализации:
val result: String = when (input) {
"ok" -> "Готово"
"error" -> fail("Ошибка")
else -> "Неизвестно" // обязателен, иначе тип результата был бы Nothing
}
Nothing? — это тип, который может быть только null. Именно к нему относится литерал null в отсутствие контекста.
Пользовательские типы: классы и другие формы определения структур данных
В Kotlin, как и в большинстве объектно-ориентированных языков, основным средством определения пользовательских типов является класс. Однако язык предоставляет несколько специализированных форм объявлений, каждая из которых решает конкретную задачу и накладывает свои ограничения и гарантии.
Обычные классы
Объявление класса в Kotlin лаконично:
class Person(val name: String, var age: Int)
Здесь конструктор является первичным (primary constructor), и параметры name и age автоматически становятся свойствами: val — неизменяемым, var — изменяемым. Это не синтаксический «сокращатель» — это полноценная идиома языка, отражающая приверженность Kotlin принципам краткости без потери выразительности.
Класс может содержать:
- вторичные конструкторы (
constructor); - свойства (
val/var); - функции-члены;
- вложенные и внутренние классы;
- init-блоки для инициализации.
Важно: если у класса отсутствует явный первичный конструктор, его можно опустить, но тогда требуется использовать фигурные скобки, даже если тело пусто:
class Empty
// эквивалентно
class Empty {}
Data-классы
Особый вид класса — data class — предназначен для хранения данных. При объявлении с ключевым словом data компилятор автоматически генерирует следующие методы:
equals()иhashCode()— на основе всех свойств, объявленных в первичном конструкторе;toString()— в формеClassName(prop1=value1, prop2=value2);copy()— функция для создания копии с изменёнными полями;- компонентные функции (
component1(),component2(), …) — для деструктуризации.
Пример:
data class Point(val x: Int, val y: Int)
val p1 = Point(1, 2)
val p2 = p1.copy(y = 5) // Point(1, 5)
val (a, b) = p1 // a = 1, b = 2
Ограничения data class:
- Должен иметь хотя бы один параметр в первичном конструкторе;
- Все параметры конструктора должны быть помечены как
valилиvar; - Не может быть абстрактным, open, sealed или внутренним;
- Наследование разрешено только от интерфейсов (не от других классов), если не указано
open.
Data-классы — идеальный выбор для передачи данных между слоями приложения: DTO, доменные сущности без поведения, параметры функций, возвращаемые значения.
Sealed-классы и интерфейсы
sealed class (и с Kotlin 1.5 — sealed interface) — механизм для определения замкнутых иерархий типов. Это означает, что все прямые подклассы должны быть объявлены в том же файле, что и сам sealed-тип. Компилятор знает полный набор возможных подтипов, что позволяет использовать when без ветки else при обработке значений sealed-типа.
sealed class Result
data class Success(val data: String) : Result()
data class Failure(val reason: String) : Result()
object Loading : Result()
Обработка:
fun handle(result: Result) = when (result) {
is Success -> println("Успех: ${result.data}")
is Failure -> println("Ошибка: ${result.reason}")
Loading -> println("Загрузка…")
// else не требуется — компилятор проверил полноту
}
Sealed-типы особенно ценны в функциональном стиле, при реализации state-машин, обработке событий и в архитектурах типа MVI или Redux, где состояние моделируется как неизменяемая сумма типов.
Enum-классы
Перечисления в Kotlin — полноценные классы, наследующие от Enum<T>. Они могут содержать свойства, методы, реализовывать интерфейсы.
enum class LogLevel(val priority: Int) {
DEBUG(1), INFO(2), WARN(3), ERROR(4);
fun shouldLog(current: LogLevel): Boolean = this.priority <= current.priority
}
Каждая константа — это singleton-объект, инициализированный при первом доступе к перечислению.
Enum-классы поддерживают стандартные методы: values(), valueOf(), ordinal, а также безопасную деструктуризацию и when-обработку.
Тип-псевдоним (typealias)
Иногда длинные обобщённые типы затрудняют чтение:
val handlers: Map<String, (Request) -> Response>
Для повышения выразительности и абстрагирования от конкретной реализации используется typealias:
typealias RequestHandler = (Request) -> Response
typealias RouteTable = Map<String, RequestHandler>
val routes: RouteTable = mapOf(
"/api/users" to { req -> UsersResponse() }
)
Важно: typealias — это чисто компиляторная замена. Во время выполнения псевдоним исчезает, и тип остаётся тем же. Это означает, что RouteTable и Map<String, (Request) -> Response> взаимозаменяемы и не дают дополнительной безопасности типов, но значительно улучшают читаемость и рефакторинг.
Обобщённые типы (generics)
Kotlin поддерживает параметризацию типов, что позволяет писать переиспользуемый и типобезопасный код. Синтаксис аналогичен Java и C#, но с рядом важных уточнений и расширений.
Базовый синтаксис
class Box<T>(val value: T)
val intBox = Box(42) // Box<Int>
val strBox = Box("Text") // Box<String>
Типовой параметр T заменяется конкретным типом на этапе компиляции (стирание типов, type erasure), но благодаря выводу типов и smart cast, безопасность сохраняется.
Ограничения типов (where и :)
Можно наложить ограничения на допустимые типы:
fun <T> T.printIfToString() where T : Any {
println(this.toString())
}
Здесь T : Any означает, что T не может быть Nothing или nullable-типом без конкретизации (например, String? допустим, так как String? : Any?, но при явном : Any требуется non-nullable). Более сложные ограничения:
fun <T> process(item: T) where T : CharSequence, T : Comparable<T> {
println("Длина: ${item.length}, сравнение: ${item.compareTo("base")}")
}
Вариантность: in, out, и проекции
Вариантность — одна из наиболее тонких тем в обобщённых типах. В Java для этого используются wildcard-типы (? extends T, ? super T). Kotlin вводит понятия ковариантности (out) и контравариантности (in) на уровне объявления типа.
out T— тип может только возвращать значенияT, но не принимать их. Это делает контейнер читаемым. Пример:List<out T>(на самом делеList<T>объявлен какinterface List<out E>).in T— тип может только принимать значенияT, но не возвращать. Это делает контейнер записываемым. Пример:Comparable<in T>(интерфейсComparable<in T>позволяет сравнивать объекты, даже если тип аргумента шире).
Пример ковариантности:
val strings: List<String> = listOf("a", "b")
val anys: List<Any> = strings // OK, потому что List<out E>
Без out такое присваивание было бы запрещено.
Для конкретных случаев, когда требуется временно изменить вариантность, Kotlin поддерживает проекции типов:
fun consume(list: List<out CharSequence>) { /* читаем, но не пишем */ }
fun produce(list: MutableList<in String>) { /* пишем String, но не читаем как String */ }
Эти механизмы позволяют использовать обобщённые типы безопасно и гибко, избегая избыточных копий и приведений.
Расширения: функции и свойства вне класса
Одна из самых выразительных возможностей Kotlin — расширения (extensions). Они позволяют добавлять новые функции и свойства к уже существующим классам без наследования и модификации исходного кода.
Функции-расширения
fun String.lastChar(): Char? = if (isEmpty()) null else this[length - 1]
println("Kotlin".lastChar()) // 'n'
Синтаксически функция выглядит как член класса, но компилируется в статический метод, принимающий экземпляр в качестве первого параметра. this внутри расширения ссылается на получатель (receiver).
Важные свойства:
- Расширения не имеют доступа к private-членам получателя;
- Они разрешаются статически, то есть при вызове
obj.extension()выбор функции зависит от объявленного типаobj, а не от его реального типа во время выполнения; - Могут быть generic:
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null.
Свойства-расширения
Аналогично можно объявлять вычисляемые свойства:
val String.wordCount: Int
get() = split(Regex("\\s+")).size
println("Hello world".wordCount) // 2
Свойства-расширения не могут иметь backing field, поэтому поддерживаются только get-выражения (и set, если свойство изменяемое — но тогда необходимо явно управлять хранением).
Расширения с ограничениями
Можно определять расширения только для подтипов:
fun <T : Comparable<T>> List<T>.isSorted(): Boolean =
zipWithNext().all { (a, b) -> a <= b }
Такое расширение доступно только для списков элементов, реализующих Comparable<T>.
Расширения — инструмент композиции. Они позволяют организовывать код по признаку использования, а не по иерархии наследования.
Делегированные свойства
Kotlin поддерживает паттерн делегирование на уровне синтаксиса. С помощью ключевого слова by свойство может делегировать свою логику получения и установки значений внешнему объекту.
Встроенные делегаты
Стандартная библиотека предоставляет несколько полезных делегатов:
by lazy { … }— отложенная инициализация (только дляval), потокобезопасная по умолчанию:
val database by lazy { Database.connect() }
by Delegates.observable(initial) { prop, old, new -> … }— выполнение callback при изменении значения (var):
var name: String by Delegates.observable("Гость") { _, old, new ->
println("Имя изменено: $old → $new")
}
-
by Delegates.vetoable { … }— возможность отменить присваивание на основе условия. -
by map— чтение изMapпо ключу, например, для конфигураций:
class Config(map: Map<String, Any?>) {
val host: String by map
val port: Int by map
}
val config = Config(mapOf("host" to "localhost", "port" to 8080))
Пользовательские делегаты
Любой класс, реализующий интерфейсы ReadOnlyProperty (для val) или ReadWriteProperty (для var), может выступать делегатом. Это позволяет реализовать кэширование, валидацию, логирование доступа и другие cross-cutting concerns без дублирования кода.
Пример: свойство, которое не может быть пустым:
class NonEmptyStringDelegate {
private var value: String = ""
operator fun getValue(thisRef: Any?, property: KProperty<*>): String = value
operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
require(newValue.isNotEmpty()) { "Значение не может быть пустым" }
value = newValue
}
}
var title: String by NonEmptyStringDelegate()
title = "Kotlin" // OK
title = "" // Исключение
Делегированные свойства усиливают декларативность кода: вместо ручного написания геттеров и сеттеров с логикой, вы указываете поведение через делегат.
Типы во взаимодействии с другими экосистемами
Совместимость с Java
Kotlin полностью совместим с JVM-экосистемой. Однако Java не различает nullable и non-nullable типы. Kotlin при компиляции аннотирует параметры и возвращаемые значения специальными аннотациями (@Nullable, @NotNull), если они присутствуют в Java-коде. При отсутствии аннотаций тип импортируется как платформенный тип (String!), который ведёт себя как «небезопасный» — можно вызывать методы без ?., но при null может возникнуть NPE.
Рекомендация: при написании Java-кода для совместного использования с Kotlin — обязательно используйте @NonNull и @Nullable из org.jetbrains.annotations или javax.annotation.
Совместимость с JavaScript (Kotlin/JS)
При компиляции в JavaScript типы стираются полностью, как и в TypeScript. Однако компилятор Kotlin/JS сохраняет проверки на null и генерирует runtime-проверки там, где это необходимо, чтобы сохранить семантику языка. Операторы ?., ?:, !! работают корректно.
Влияние на сериализацию и отражение
При использовании библиотек вроде kotlinx.serialization, Jackson или Gson, nullable-тип влияет на поведение по умолчанию:
String?сериализуется какnull, если значениеnull;String(non-nullable) требует значения — при отсутствии поля в JSON может быть использовано значение по умолчанию или выброшено исключение;- Data-классы с nullable-полями легко описывают необязательные поля в API.
Аннотации вроде @SerialName, @Required, @Transient позволяют тонко настраивать соответствие.